Skip to main content

copp\copp\copp3\opt3/
topp3_lp.rs

1//! 3rd-order Time-Optimal Path Parameterization (TOPP3) based on linear programming (LP).
2//!
3//! # Method identity
4//! This module implements the **optimization backend** for TOPP3-LP by transforming
5//! third-order path-parameterization constraints/objective into Clarabel-compatible
6//! conic form and solving with LP.
7//!
8//! # Discrete variables (local notation)
9//! On a path grid `s[0..=n]`:
10//! - `a[k]` denotes $\dot{s}_k^2$;
11//! - `b[k]` denotes $\ddot{s}_k$;
12//! - decision vector is organized as `x = [a[0..=n], b[0..=n]]`.
13//!
14//! # High-level pipeline
15//! 1. Validate boundary/index contracts.
16//! 2. Assemble standard TOPP3 conic constraints.
17//! 3. Build sparse matrices `A`, `P`, vector `q`, and solve by Clarabel.
18//! 4. Apply status acceptance policy (`ClarabelOptions::is_allow`) and extract
19//!    `(a,b,num_stationary)` only when accepted.
20//!
21//! # API layering
22//! - [`topp3_lp`]: strict/normal API, returns only accepted `(a,b,num_stationary)`.
23//! - [`topp3_lp_expert`]: expert API returning `(Option<Copp3Result>, DefaultSolution<f64>)`.
24
25use crate::copp::copp3::Copp3Result;
26use crate::copp::copp3::formulation::{Topp3Problem, get_weight_a_topp3};
27use crate::copp::copp3::opt3::clarabel_constraints::{
28    clarabel_standard_capacity_topp3, clarabel_standard_constraint_topp3,
29};
30use crate::copp::{ClarabelOptions, clarabel_to_copp3_solution};
31use crate::diag::{
32    CoppError, DebugVerboser, SilentVerboser, SummaryVerboser, TraceVerboser, Verboser, Verbosity,
33    check_boundary_state_copp3_valid, check_s_interval_valid, format_duration_human,
34};
35use clarabel::algebra::CscMatrix;
36use clarabel::solver::{DefaultSolution, DefaultSolver, IPSolver, SupportedConeT};
37use core::f64;
38
39/// Strict TOPP3-LP API for production use.
40///
41/// # Purpose
42/// Use this entry when caller only needs a valid profile `(a,b,num_stationary)` and treats
43/// non-accepted solver statuses as hard failures.
44///
45/// # Contract
46/// - Internally calls [`topp3_lp_expert`].
47/// - Returns `Ok((a,b,num_stationary))` **iff** `options.is_allow(solution.status)` is `true`.
48/// - Returns `Err(CoppError::ClarabelSolverStatus(...))` when status is not accepted.
49///
50/// # Returns
51/// Returns accepted TOPP3 profile `(a, b, num_stationary)`.
52///
53/// # Errors
54/// Returns [`CoppError`] on model/solver failures and non-accepted solver status.
55///
56/// More details are provided in the documentation of [`topp3_lp_expert`].
57pub fn topp3_lp(
58    problem: &Topp3Problem,
59    options: &ClarabelOptions,
60) -> Result<Copp3Result, CoppError> {
61    let (result, solution) = topp3_lp_expert(problem, options)?;
62    result.ok_or_else(|| CoppError::ClarabelSolverStatus("topp3_lp".into(), solution.status))
63}
64
65/// Expert TOPP3-LP API with full Clarabel solution exposure.
66///
67/// # Return contract
68/// - `Ok((Some(result), solution))`: status accepted by `options.is_allow(solution.status)`.
69/// - `Ok((None, solution))`: solve finished but status not accepted.
70/// - `Err(...)`: input/model/solver-construction runtime failures.
71///
72/// # Returns
73/// Returns tuple `(Option<Copp3Result>, DefaultSolution<f64>)` for diagnostics.
74///
75/// # Errors
76/// Returns [`CoppError`] only for real build/runtime failures.
77///
78/// # Contract
79/// - caller handles `None` profile when status is not accepted;
80/// - acceptance policy is fully defined by `options.is_allow`.
81///
82/// # Verbosity behavior
83/// Logging is layered by `options.verbosity()`:
84/// - [`Silent`](Verbosity::Silent): no algorithm logs;
85/// - [`Summary`](Verbosity::Summary): lifecycle milestones and elapsed time;
86/// - [`Debug`](Verbosity::Debug): assembly-level counters and stage summaries;
87/// - [`Trace`](Verbosity::Trace): fine-grained stage deltas and solver snapshot diagnostics.
88pub fn topp3_lp_expert(
89    problem: &Topp3Problem,
90    options: &ClarabelOptions,
91) -> Result<(Option<Copp3Result>, DefaultSolution<f64>), CoppError> {
92    match options.verbosity() {
93        Verbosity::Silent => topp3_lp_core(problem, (options, SilentVerboser)),
94        Verbosity::Summary => topp3_lp_core(problem, (options, SummaryVerboser::new())),
95        Verbosity::Debug => topp3_lp_core(problem, (options, DebugVerboser::new())),
96        Verbosity::Trace => topp3_lp_core(problem, (options, TraceVerboser::new())),
97    }
98}
99
100/// Core implementation for TOPP3-LP expert flow.
101///
102/// # Internal contract
103/// `options_verboser` packs:
104/// - `options`: acceptance policy and Clarabel numerical settings;
105/// - `verboser`: concrete logger implementation chosen by external verbosity dispatch.
106///
107/// # Invariants
108/// - decision-variable layout is always `x = [a[0..=n], b[0..=n]]`;
109/// - extracted `(a,b)` is produced only through `clarabel_to_copp3_solution` when status is accepted.
110fn topp3_lp_core(
111    problem: &Topp3Problem,
112    options_verboser: (&ClarabelOptions, impl Verboser),
113) -> Result<(Option<Copp3Result>, DefaultSolution<f64>), CoppError> {
114    let (options, mut verboser) = options_verboser;
115    let idx_s_start = problem.idx_s_start;
116    let a_boundary = problem.a_boundary;
117    let b_boundary = problem.b_boundary;
118    let num_stationary = problem.num_stationary;
119    if verboser.is_enabled(Verbosity::Summary) {
120        verboser.record_start_time();
121    }
122    if verboser.is_enabled(Verbosity::Trace) {
123        let settings = options.clarabel_settings();
124        crate::verbosity_log!(
125            crate::diag::Verbosity::Summary,
126            "topp3_lp: options snapshot -> allow(almost={}, max_iter={}, max_time={}, callback_term={}, insufficient_progress={}), tol_gap_rel={}, tol_feas={}, max_iter={}, verbose={}",
127            options.is_allow(clarabel::solver::SolverStatus::AlmostSolved),
128            options.is_allow(clarabel::solver::SolverStatus::MaxIterations),
129            options.is_allow(clarabel::solver::SolverStatus::MaxTime),
130            options.is_allow(clarabel::solver::SolverStatus::CallbackTerminated),
131            options.is_allow(clarabel::solver::SolverStatus::InsufficientProgress),
132            settings.tol_gap_rel,
133            settings.tol_feas,
134            settings.max_iter,
135            settings.verbose
136        );
137    }
138
139    // Check input validity
140    check_boundary_state_copp3_valid(a_boundary, b_boundary)?;
141    let n = problem.a_linearization.len() - 1;
142    let idx_s_final = idx_s_start + n;
143    if verboser.is_enabled(Verbosity::Summary) {
144        crate::verbosity_log!(
145            crate::diag::Verbosity::Summary,
146            "\ntopp3_lp started: {} <= idx_s <= {}, s_len = {}, num_stationary={:?}.",
147            idx_s_start,
148            idx_s_final,
149            problem.a_linearization.len(),
150            num_stationary
151        );
152    }
153    check_s_interval_valid("topp3_lp", idx_s_start, idx_s_final)?;
154    // Let x = [a[0,1,...,n], b[0,1,...,n]] \in R^{2*(n+1)}.
155    // Step 1. Deal with constraints
156    // s=b-A*x \in cone, where A[row[i],col[i]]=val[i], A \in R^{m*(n+1)}, b \in R^m, s \in R^m
157    // -s=-b+A*x
158    // Step 1.1 create constraints
159    let (capacity_val, capacity_b, capacity_cones) =
160        clarabel_standard_capacity_topp3(problem.constraints, (idx_s_start, idx_s_final));
161    if verboser.is_enabled(Verbosity::Debug) {
162        crate::verbosity_log!(
163            crate::diag::Verbosity::Summary,
164            "topp3_lp: capacity estimate standard(val={capacity_val}, b={capacity_b}, cone={capacity_cones}), n_var={}",
165            2 * (n + 1)
166        );
167    }
168    let mut cones = Vec::<SupportedConeT<f64>>::with_capacity(capacity_cones);
169    let mut row = Vec::<usize>::with_capacity(capacity_val);
170    let mut col = Vec::<usize>::with_capacity(capacity_val);
171    let mut val = Vec::<f64>::with_capacity(capacity_val);
172    let mut b = Vec::<f64>::with_capacity(capacity_b);
173    if verboser.is_enabled(Verbosity::Trace) {
174        crate::verbosity_log!(
175            crate::diag::Verbosity::Summary,
176            "topp3_lp: allocated capacities row/col/val/b/cones <= {capacity_val}/{capacity_val}/{capacity_val}/{capacity_b}/{capacity_cones}",
177        );
178    }
179
180    // Step 1.2 deal with standard constraints
181    let s = problem.constraints.s_vec(idx_s_start, idx_s_final + 1)?;
182    let row_before_std = row.len();
183    let col_before_std = col.len();
184    let val_before_std = val.len();
185    let b_before_std = b.len();
186    let cones_before_std = cones.len();
187    clarabel_standard_constraint_topp3(
188        problem,
189        &s,
190        (&mut row, &mut col, &mut val, &mut b, &mut cones),
191        num_stationary,
192        &verboser,
193    )?;
194    if verboser.is_enabled(Verbosity::Trace) {
195        crate::verbosity_log!(
196            crate::diag::Verbosity::Summary,
197            "topp3_lp: standard-constraints delta row/col/val/b/cones = +{}/+{}/+{}/+{}/+{}",
198            row.len() - row_before_std,
199            col.len() - col_before_std,
200            val.len() - val_before_std,
201            b.len() - b_before_std,
202            cones.len() - cones_before_std
203        );
204    }
205
206    // Step 1.3 build the constraints
207    let n_var = 2 * (n + 1);
208    let row_len = row.len();
209    let col_len = col.len();
210    let val_len = val.len();
211    let b_len = b.len();
212    let cones_len = cones.len();
213    let a_csc = CscMatrix::new_from_triplets(b.len(), n_var, row, col, val);
214    // Step 2. objective function. max: \int a(s) ds
215    let p_object = CscMatrix::<f64>::zeros((n_var, n_var));
216    let q_object = clarabel_q_object_topp3_lp(&s, num_stationary, n_var);
217    if verboser.is_enabled(Verbosity::Trace) {
218        let (q_min, q_max) = q_object
219            .iter()
220            .fold((f64::INFINITY, f64::NEG_INFINITY), |(mn, mx), &v| {
221                (mn.min(v), mx.max(v))
222            });
223        crate::verbosity_log!(
224            crate::diag::Verbosity::Summary,
225            "topp3_lp: matrix built with m={}, n={}, A.nnz={}, P.nnz={}, q_range=[{}, {}]",
226            b_len,
227            n_var,
228            a_csc.nnz(),
229            p_object.nnz(),
230            q_min,
231            q_max
232        );
233    }
234    if verboser.is_enabled(Verbosity::Summary) {
235        crate::verbosity_log!(
236            crate::diag::Verbosity::Summary,
237            "topp3_lp: ready to solve with row/col/val/b/cones = {row_len}/{col_len}/{val_len}/{b_len}/{cones_len} and n_var = {n_var}.",
238        );
239    }
240    // Step 3. solve the LP problem
241    let settings = options.clarabel_settings().clone();
242    let mut solver = DefaultSolver::<f64>::new(&p_object, &q_object, &a_csc, &b, &cones, settings)
243        .map_err(|e| CoppError::ClarabelSolverError("topp3_lp".into(), e))?;
244    solver.solve();
245    let solution = solver.solution;
246    if verboser.is_enabled(Verbosity::Summary) {
247        crate::verbosity_log!(
248            crate::diag::Verbosity::Summary,
249            "topp3_lp: solve done, status = {:?}, elapsed = {}.",
250            solution.status,
251            format_duration_human(verboser.elapsed())
252        );
253    }
254    if verboser.is_enabled(Verbosity::Trace) {
255        let show = solution.x.len().min(3);
256        crate::verbosity_log!(
257            crate::diag::Verbosity::Summary,
258            "topp3_lp: solution x_len={}, head={:?}",
259            solution.x.len(),
260            &solution.x[0..show]
261        );
262    }
263    let result = if options.is_allow(solution.status) {
264        let (a, b) =
265            clarabel_to_copp3_solution(&solution.x.as_slice()[0..2 * (n + 1)], &s, num_stationary);
266        Some((a, b, num_stationary))
267    } else {
268        None
269    };
270    if verboser.is_enabled(Verbosity::Trace) {
271        crate::verbosity_log!(
272            crate::diag::Verbosity::Summary,
273            "topp3_lp: allow(status)={}, extracted_profile={}",
274            options.is_allow(solution.status),
275            if result.is_some() {
276                "Some((a,b,num_stationary))"
277            } else {
278                "None"
279            }
280        );
281    }
282    Ok((result, solution))
283}
284
285/// Build LP objective vector for TOPP3-LP in Clarabel form.
286///
287/// # Definition
288/// The primal objective is `max \int a(s) ds`, converted to minimization as
289/// `min \int -a(s) ds`.
290///
291/// # Layout
292/// - first block (`a`) gets negated quadrature weights;
293/// - second block (`b`) is zero-padded.
294#[inline(always)]
295fn clarabel_q_object_topp3_lp(s: &[f64], num_stationary: (usize, usize), n_var: usize) -> Vec<f64> {
296    let mut q_object = get_weight_a_topp3(s, num_stationary);
297    // max \int a(s) ds <=> min \int -a(s) ds
298    q_object.iter_mut().for_each(|q_i| *q_i = -*q_i);
299    q_object.resize(n_var, 0.0);
300    q_object
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306    use crate::copp::ClarabelOptionsBuilder;
307    use crate::copp::InterpolationMode;
308    use crate::copp::copp2::stable::basic::{Topp2ProblemBuilder, s_to_t_topp2};
309    use crate::copp::copp2::stable::reach_set2::ReachSet2OptionsBuilder;
310    use crate::copp::copp2::stable::topp2_ra::topp2_ra;
311    use crate::copp::copp3::stable::basic::{Topp3ProblemBuilder, s_to_t_topp3, t_to_s_topp3};
312    use crate::path::add_symmetric_axial_limits_for_test;
313    use crate::robot::robot_core::Robot;
314    use nalgebra::DMatrix;
315    use rand::RngExt;
316    use std::time::Instant;
317
318    #[test]
319    fn test_topp3_lp() -> Result<(), CoppError> {
320        run_test_topp3_lp_repeated(1, false)
321    }
322
323    /// Conditions: release, --include-ignored, CPU = Intel(R) Core(TM) Ultra 9 285K.
324    /// Average over 100 experiments: tc_ra = 0.3417 ms, tc_lp = 261.8795 ms, tf_ra = 6.138643, tf_lp = 7.051755
325    #[test]
326    #[ignore = "slow"]
327    fn test_topp3_lp_robust() -> Result<(), CoppError> {
328        run_test_topp3_lp_repeated(100, true)
329    }
330
331    fn run_one_topp3_lp_case(
332        options_lp: &ClarabelOptions,
333    ) -> Result<(f64, f64, f64, f64, f64, usize), CoppError> {
334        let n: usize = 1000;
335        let dim = 7;
336        let mut rng = rand::rng();
337        let omega = (0..dim)
338            .map(|_| rng.random_range(0.1..(2.0 * f64::consts::PI)))
339            .collect::<Vec<f64>>();
340        let phi = (0..dim)
341            .map(|_| rng.random_range(0.0..(2.0 * f64::consts::PI)))
342            .collect::<Vec<f64>>();
343
344        let mut robot = Robot::with_capacity(dim, n);
345        let s = DMatrix::<f64>::from_fn(1, n, |_, j| {
346            (j as f64
347                + (if 0 < j && 2 * j < n { 0.5 } else { 0.0 }
348                    + if n > j && 2 * j > n { 0.5 } else { 0.0 })
349                    * j as f64
350                    / n as f64)
351                * (1.0 / (n - 1) as f64)
352        });
353        let q = DMatrix::<f64>::from_fn(dim, n, |i, j| (omega[i] * s[j] + phi[i]).sin());
354        let dq =
355            DMatrix::<f64>::from_fn(dim, n, |i, j| omega[i] * (omega[i] * s[j] + phi[i]).cos());
356        let ddq = DMatrix::<f64>::from_fn(dim, n, |i, j| {
357            -omega[i] * omega[i] * (omega[i] * s[j] + phi[i]).sin()
358        });
359        let dddq = DMatrix::<f64>::from_fn(dim, n, |i, j| {
360            -omega[i] * omega[i] * omega[i] * (omega[i] * s[j] + phi[i]).cos()
361        });
362        robot.with_s(&s.as_view())?;
363        robot.with_q(
364            &q.as_view(),
365            &dq.as_view(),
366            &ddq.as_view(),
367            Some(&dddq.as_view()),
368            0,
369        )?;
370        add_symmetric_axial_limits_for_test(&mut robot, 1.0, 1.0, Some(5.0))?;
371
372        let topp2_problem = Topp2ProblemBuilder::new(&robot, (0, n - 1), (0.0, 0.0)).build()?;
373        let start = Instant::now();
374        let options_ra = ReachSet2OptionsBuilder::new()
375            .lp_feas_tol(1E-9)
376            .a_cmp_abs_tol(1E-9)
377            .a_cmp_rel_tol(1E-9)
378            .build()?;
379        let a_profile_ra = topp2_ra(&topp2_problem, &options_ra)?;
380        let time_topp_ra = start.elapsed().as_secs_f64() * 1E3;
381        let (t_motion_ra, _) = s_to_t_topp2(s.as_slice(), &a_profile_ra, 0.0);
382
383        let start = Instant::now();
384        robot.constraints.amax_substitute(&a_profile_ra, 0)?;
385        let topp3_problem =
386            Topp3ProblemBuilder::new(&mut robot, 0, &a_profile_ra, (0.0, 0.0), (0.0, 0.0))
387                .with_num_stationary_max(2)
388                .build_with_linearization()?;
389        let (a_profile, b_profile, num_stationary) = topp3_lp(&topp3_problem, options_lp)?;
390        let time_topp3_lp = start.elapsed().as_secs_f64() * 1E3;
391        let start = Instant::now();
392        let (t_motion_lp, t_s) =
393            s_to_t_topp3(s.as_slice(), &a_profile, &b_profile, num_stationary, 0.0);
394        let s_t = t_to_s_topp3(
395            s.as_slice(),
396            &a_profile,
397            &b_profile,
398            num_stationary,
399            &t_s,
400            InterpolationMode::UniformTimeGrid(0.0, 1E-3, true),
401        );
402        let time_interpolation = start.elapsed().as_secs_f64() * 1E3;
403        Ok((
404            time_topp_ra,
405            time_topp3_lp,
406            time_interpolation,
407            t_motion_ra,
408            t_motion_lp,
409            s_t.len(),
410        ))
411    }
412
413    fn run_test_topp3_lp_repeated(n_exp: usize, flag_print_step: bool) -> Result<(), CoppError> {
414        let options_lp = ClarabelOptionsBuilder::new()
415            .allow_almost_solved(true)
416            .build()?;
417
418        let mut tc_sum_ra = 0.0;
419        let mut tc_sum_lp = 0.0;
420        let mut tf_sum_ra = 0.0;
421        let mut tf_sum_lp = 0.0;
422        for i_exp in 0..n_exp {
423            let (
424                time_topp_ra,
425                time_topp3_lp,
426                time_interpolation,
427                t_motion_ra,
428                t_motion_lp,
429                s_t_len,
430            ) = run_one_topp3_lp_case(&options_lp)?;
431
432            if flag_print_step {
433                crate::verbosity_log!(
434                    crate::diag::Verbosity::Summary,
435                    "Exp #{}: tc_ra = {:.4} ms, tc_lp = {:.4} ms, tc_interpolation = {:.4} ms, tf_ra = {:.6}, tf_lp = {:.6}, s_t.len() = {}",
436                    i_exp + 1,
437                    time_topp_ra,
438                    time_topp3_lp,
439                    time_interpolation,
440                    t_motion_ra,
441                    t_motion_lp,
442                    s_t_len,
443                );
444            }
445
446            tc_sum_ra += time_topp_ra;
447            tc_sum_lp += time_topp3_lp;
448            tf_sum_ra += t_motion_ra;
449            tf_sum_lp += t_motion_lp;
450        }
451
452        crate::verbosity_log!(
453            crate::diag::Verbosity::Summary,
454            "Average over {} experiments: tc_ra = {:.4} ms, tc_lp = {:.4} ms, tf_ra = {:.6}, tf_lp = {:.6}",
455            n_exp,
456            tc_sum_ra / n_exp as f64,
457            tc_sum_lp / n_exp as f64,
458            tf_sum_ra / n_exp as f64,
459            tf_sum_lp / n_exp as f64,
460        );
461
462        Ok(())
463    }
464}